Fix PMS atomic persistence and truthful save responses - #5666
Conversation
|
Thanks for reporting this, @rfoust. To help us track it down, could you share a few more details?
If you can attach logs (Help → Support → File an Issue), that would be especially helpful. — AetherClaude (automated agent for AetherSDR) 🤖 aethersdr-agent · cost: $4.0525 · model: claude-opus-5 |
There was a problem hiding this comment.
1. Issue fit
Yes. #5664 asks for five things and the diff delivers all five: atomic whole-document replacement (writeJsonAtomically, QSaveFile with setDirectWriteFallback(false) — the right call, since a fallback direct write is exactly the truncation the issue is about); surfaced open/write/commit failures (reportPersistenceFailure → qCWarning(lcAx25) + activity); acknowledgement only after commit (cmdRead, cmdKill, finishCompose all build a candidate vector, save it, and adopt it only on success); m_nextId consumed only on a committed write; and the prior document preserved on failure.
I checked the "keep in-memory consistent with the last successful write" requirement specifically: m_messages is now assigned only via std::move(updated) after a true return in all three mutators — there is no path left that mutates m_messages and then saves. m_heard and m_callers are still mutated before their save and keep the mutation on failure, but the issue scopes that requirement to message data / read flags / next ID, and those two stores are self-healing (the next successful save carries the pending entry), so I do not read that as unaddressed.
Socket disclosure: the added test binds nothing. It is pure file I/O plus the existing in-memory onAirFrame/Frame::encode() injection seam — no QTcpServer/QUdpSocket/QLocalServer, no peer process, no Fake* radio. Nothing to record beyond that.
2. Scope
| File | What it changes | Claimed by the issue? | Verdict |
|---|---|---|---|
src/core/pms/PmsMailbox.cpp |
QSaveFile helper; save* return bool and take the candidate by value; ensureStorageDir returns its result; transactional read/kill/compose; reportPersistenceFailure |
Yes — all five requirements | In scope |
src/core/pms/PmsMailbox.h |
Signature changes only, all under private: (verified at PmsMailbox.h:150) — no new public/protocol/settings surface |
Yes | In scope |
tests/pms_mailbox_test.cpp |
One new test on the already-registered pms_mailbox_test target |
Yes — issue asks for failure/recovery/restart/atomicity coverage | In scope |
No CHANGELOG.md edit, no new settings key, no new RadioCapabilities bool, no build-config churn. The only thing in the diff no requirement strictly explains is the brace addition around m_callers.remove(...) in recordCaller (PmsMailbox.cpp:410-412) — one line of style, not worth a row.
3. Blockers
None.
4. Nits (non-blocking)
- A caller can be trapped in compose mode by a persistent storage failure — inline at
PmsMailbox.cpp:771. Retaining the draft is the right trade over the old falseSAVED, but there is no abort verb:finishCompose(false)has no caller anywhere in the file (so"Message aborted."is dead onmaintoo), andonLinkDatacallstouchSession()on every byte, so the 10-minute idle timer re-arms with each retry. Bounded only by the caller sending DISC. No data is lost either way. - The destructor's save can now emit a signal — inline at
PmsMailbox.cpp:103.saveHeardwasconstand silent before this PR; it can now reachemit activity(...), andm_pmsis a child ofAx25HfPacketDecodeDialog(Ax25HfPacketDecodeDialog.cpp:852, connected toappendSystemLineat :1253), so on the failure path the slot runs after the parent's derived destructor has completed. m_draft = candidate;atPmsMailbox.cpp:776is a dead store —m_draftis fully reset bycmdSendBeginbefore it is next read. Harmless; it just reads as if it mattered.- Sibling left behind, out of scope:
src/core/tnc/HeardList.cpp:196has the identical defect class —WriteOnly | Truncatewith thef.write(...)result discarded. It is the TNC's own heard list, not one of the three PMS stores #5664 names, so this is a completeness note rather than something to bundle here.
5. What I tried to break
- "Atomic replacement" — I checked the fixture actually exercises the path it claims. The test makes the target path a directory. Qt's
QSaveFile::open()has an explicitexistingFile.isDir()→WriteErrorbranch before it creates its temp file, so the failure is real and not an accident of permissions. And the hard-link assertions would fail against the unfixedQFiletruncate (the link would observe the new bytes), so they are load-bearing rather than self-confirming. - I tried to find a surviving path that mutates state before the save. Grepped every
m_messageswrite andsaveMessagescall site in the head checkout:cmdRead,cmdKill,finishComposeandloadAllare the only ones, and the first three all adopt viastd::moveafter atrue.updated.remove(i)/updated[i]index a copy ofm_messagestaken in the same scope, so the indices cannot skew. - I tried to break the ID accounting.
finishComposewritesm_nextId + 1while assigningcandidate.id = m_nextId, and bumps the member only after commit; the test'snextId == 3assertion after two commits and one failure would catch a consumed ID.loadAlladditionally re-derivesm_nextId = max(nextId, id+1), so even a stale header self-corrects. - Multi-instance interference in the new test. The
callerWriter/callerFailurescopes construct secondPmsMailboxobjects against the sameAETHER_PMS_DIRwhilepmsis still connected; I traced their destructors (saveHeardonly) against the order of thedurableHeard/durableCallerscaptures and found no fixture that reads a file another instance rewrites between capture and assertion. - Could not check, and saying so:
pms_mailbox_testappears in noctest -Rfilter in.github/workflows/ci.yml, so the five green checks prove the new test compiles and links on Linux/macOS/Windows — not that it passes. In particular thestd::filesystem::create_hard_linkfixture is unexercised by CI and will hard-fail (not skip) on a filesystem without hard-link support. Worth one local Windows run before merge. - All findings above are reasoned from the head checkout at
/tmp/aetherclaude/pr-5666; I ran no build and executed no test.
6. Recommendation
Approve with nits. The fix matches the issue requirement-for-requirement, the seam it chose (candidate-vector → save → adopt) is the right one, it adds no public surface, and the test is adversarial enough to fail against the unfixed code. The compose-trap and destructor-emit items are worth a follow-up commit but neither breaks a user or violates canon. Next step: confirm the new test passes on Windows locally, then merge.
Nice work — the setDirectWriteFallback(false) comment in particular explains why rather than what, which is exactly the house style.
🤖 aethersdr-agent · cost: $8.8480 · model: claude-opus-5
K5PTB
left a comment
There was a problem hiding this comment.
Issue fit
Yes. #5664 asks for five things, and each maps to a hunk:
- atomic whole-document replacement (
QSaveFilewithsetDirectWriteFallback(false)); - surfaced failures (
reportPersistenceFailure); - acknowledgement only after commit (read, kill and compose build a candidate, save it, then adopt it);
- a message ID consumed only on commit;
- the previous document preserved.
I confirmed the transactional part with mutants (below). One regression came with it: the new failure reporting can now fire from a destructor, and in the app's own ownership shape that aborts.
Scope
| File | What it changes | Claimed? | Verdict |
|---|---|---|---|
src/core/pms/PmsMailbox.cpp |
writeJsonAtomically, bool saves over candidates, transactional read/kill/compose, reportPersistenceFailure |
yes | in scope |
src/core/pms/PmsMailbox.h |
private signature changes only | yes | in scope |
tests/pms_mailbox_test.cpp |
one new case on the registered target | yes | in scope |
Everything in the diff is explained by the issue: no CHANGELOG.md, no settings key, no public surface. Sockets: none; the test uses the existing in-memory frame injection. pms_mailbox_test matches no ci.yml -R filter, so the green checks compiled it and did not run it. It first runs in full-suite.yml.
Blockers
1. ~PmsMailbox can now emit activity into its already-destroyed parent dialog, and Qt aborts. (inline PmsMailbox.cpp:100)
Before this PR, saveHeard() was silent on failure. Now the destructor's save reaches emit activity(...). In the app, m_pms is a child of Ax25HfPacketDecodeDialog (Ax25HfPacketDecodeDialog.cpp:852), wired directly to the derived member appendSystemLine (:1253). The dialog's destructor never disconnects it, and QWidget::~QWidget deletes children before ~QObject disconnects anything. So a heard-list write that fails at shutdown calls a member function on a dialog whose derived part is gone — the storage-failure path this PR exists to make safe.
Reproduced with a review-only probe using the same shape: a QWidget subclass, a PmsMailbox child, activity connected to a derived member slot, and heard.json blocked by a directory:
~Dlg body done
aether.ax25: PMS could not save heard stations: Filename refers to a directory
ASSERT failure in QWidget: "Called object is not of the correct type (class destructor may have already run)", file .../QtCore.framework/Headers/qobjectdefs_impl.h, line 107
exit=134
That is with assertions enabled in the local Qt build (Homebrew, macOS). Without them, the same call runs a member function on a destroyed object. The one-line suggestion inline blocks signals for the destructor's save only, and keeps the qCWarning. With it applied, the probe reports calls into destroyed derived dialog: 0 and exits 0, and pms_mailbox_test still passes.
Nits (non-blocking)
- The test never reaches the direct-write fallback it disables (inline
PmsMailbox.cpp:40). WithsetDirectWriteFallback(true), the whole suite still passes: a directory at the target path failsQSaveFile::openbefore any fallback applies. A read-only store directory is the fixture that exercises it (inline atpms_mailbox_test.cpp:666). - The recovered compose is not checked for the retained body (inline
pms_mailbox_test.cpp:625). Clearingm_draftLineson the failure path still passes, because an empty-body message also readsSAVEDwith count 2. - I agree with the earlier review's compose-trap note (no abort verb while storage stays broken). It's not repeated inline.
What I tried to break
-
Built
pms_mailbox_testat302c6688(Debug, macOS,-j4): all pass. -
Mutants. Caught:
- skipping
commit(): 23 failures, including all three hard-link snapshot checks, so atomic replacement is really exercised; - adopting the kill before its save;
- adopting the read flag on failure;
- consuming an ID on a failed compose;
- not emitting
activity; - the heard save ignoring failure.
Survived:
- the fallback flag (nit 1);
- dropping the draft on failure (nit 2);
write() < 0in place of the full-length check, which can't be triggered without an injection seam, so not a finding.
A plain-
QFilewriter mutant didn't compile (nocommit()) and was not run. - skipping
-
Suggested tests, checked both ways:
- the read-only-directory case passes at the head, and fails 3 checks under the fallback mutant;
- the body check (
body == "body", since the first compose line is the subject) passes at the head and fails under the draft-dropping mutant.
-
Heard-save cadence.
recordHeardsaves only for a new station, so a persistently failing store cannot flood the activity log at packet rate. -
Interaction with #5659, which also edits
PmsMailboxand this test target:git merge-treeshows no conflict between the two heads. -
Not driven in the app: the bridge has no PMS frame or storage-fault injection, and teardown ordering is what the probe covers.
Recommendation
Request changes — one line. The persistence fix is right and well tested. Blocker 1 is a crash the PR introduces on the failure path it is fixing, and the validated suggestion closes it. The two test additions are optional, but each closes a mutant that currently survives.
jensenpat
left a comment
There was a problem hiding this comment.
Issue fit
Yes. #5664 requires atomic whole-document replacement, surfaced open/write/commit failures, acknowledgement only after commit, nextId consumed only on a committed write, and preservation of the previous document. The hunks map 1:1: writeJsonAtomically (QSaveFile + setDirectWriteFallback(false)), reportPersistenceFailure, candidate-vector then adopt in cmdRead / cmdKill / finishCompose, and hard-link assertions in testPersistenceIsAtomicAndTransactional.
m_heard / m_callers still mutate before save and keep the mutation on failure. The issue scopes transactional consistency to message data / read flags / next ID, so that is not a miss.
Scope
| File | Change | Claimed? | Verdict |
|---|---|---|---|
src/core/pms/PmsMailbox.cpp |
Atomic JSON helper; bool saves; transactional read/kill/compose; failure reporting | yes | in scope |
src/core/pms/PmsMailbox.h |
Private signature changes only | yes | in scope |
tests/pms_mailbox_test.cpp |
One new case on the existing pms_mailbox_test target |
yes | in scope |
No CHANGELOG, settings, public/protocol surface, or radio-family change. Sockets: none in the new test (in-memory onAirFrame / Frame::encode() only).
Blockers
1. ~PmsMailbox can emit activity into an already-destroyed parent dialog. (inline PmsMailbox.cpp:103)
This is the same defect K5PTB requested changes for; it is still present on 302c6688. Before this PR, saveHeard() was silent on failure. Now the destructor reaches reportPersistenceFailure → emit activity(...). In the app, m_pms is a child of Ax25HfPacketDecodeDialog (Ax25HfPacketDecodeDialog.cpp:852) and activity is connected to the derived slot appendSystemLine (:1253). QWidget::~QWidget deletes children after the derived destructor has finished, so a failing heard save at teardown calls a member on a dialog whose derived part is gone. Qt debug builds abort; release builds are UAF.
Fix: block signals around the destructor save (keep qCWarning). Do not merge until that lands.
Nits (non-blocking)
- Compose has no abort while storage stays down (
finishCompose(false)is still unused). Better than the old falseSAVED, not a merge gate. m_draft = candidateafter a successful compose is unused until the nextcmdSendBeginresets it.- The directory-at-path fixture does not exercise
setDirectWriteFallback(false); that is still the correct flag. pms_mailbox_testis not inci.yml-Rfilters; default CI compiled it, did not run it.
Verification
Source review of the three-file diff at 302c66883d64cd97b945ac078539bc911973a065 (blob SHAs match the PR snapshot). No local build this pass: the destructor emit is a demonstrated defect from the app ownership graph plus K5PTB's probe, and it already blocks merge.
CI on this SHA: CI build / check-macos / check-windows SUCCESS; Static checks SUCCESS. Required checks green does not override the lifetime defect.
Recommendation
Request changes. Persistence semantics match #5664. Do not merge until the destructor does not emit into a destroyed parent. After that one-line (or equivalent) fix, re-run pms_mailbox_test and this review can approve.
QWidget deletes PmsMailbox after the parent dialog's derived slots are gone. Block signals around the destructor heard save so a failed write cannot emit activity into that destroyed object. Fixes the remaining aethersdr#5666 review blocker.
Destructor activity emit fixed in daf401d (QSignalBlocker + regression).
jensenpat
left a comment
There was a problem hiding this comment.
Issue fit
Yes. #5664 still maps 1:1. Follow-up daf401dc blocks activity in ~PmsMailbox via QSignalBlocker so a failed heard save cannot reach Ax25HfPacketDecodeDialog::appendSystemLine after the derived destructor.
Scope
Unchanged plus the destructor guard and a regression in pms_mailbox_test. No public surface.
Blockers
None on this SHA.
Verification
- Built
[pms_mailbox_test](/tmp/aether-review-pr-5666/build/pms_mailbox_test)(app not rebuilt). QT_QPA_PLATFORM=offscreen ctest -R '^pms_mailbox_test$'passed (1/1).- Mutation: drop
QSignalBlocker→ FAILdestructor save does not emit activity on failure; restored and passed.
Recommendation
Approve. Merge when required CI on daf401dc is green.
PMS previously replied
MESSAGE n SAVED.andMessage n killed.even whenmessages.jsoncould not be written. Its messages, callers, and heard stores also used unchecked truncating writes, risking loss of the previous complete document.This change writes each JSON document with
QSaveFile, disables direct-write fallback, and checks the complete write and commit. Message additions, deletions, read flags, and the next ID enter the live mailbox only after persistence succeeds. Failed compose retains the draft for/EXretry; failed delete/read-state updates return an error. All three stores report failures through mailbox activity andlcAx25. The JSON format and heard/caller save cadence are unchanged.Fixes #5664.
Validation
pms_mailbox_testwith injected AX.25 commands: save/read/delete failures, draft retry, ID stability, read recovery, restart, and failure reporting for all three stores.upstream/mainPMS sources (87b80c65d): 16 assertions failed, including old-snapshot preservation and false success. Restored the fix and passed.link status→pms.messages=1,callerConnected=false;get radio→connected=false;get transmit→transmitting=false. PMS sent zero I-frames. Bridgeclose MainWindowsucceeded and the launched process exited 0. No live radio was connected.The agent automation bridge has no PMS frame-injection or persistence-failure verb. Failure semantics are demonstrated by the injected-frame regression test; native reload/status evidence is reported separately. No live radio or RF transmission is used.
Generated with OpenAI Codex.